MBL
Go / avito / Тестовое задание / Postgres: изоляция транзакций, блокировки, deadlock
Go сложный

Postgres: изоляция транзакций, блокировки, deadlock

postgressqllockingtransactions

Продолжение статьи «Защита тестового Avito на реальном проекте: Avito.Кухня» — там уже разобран атомарный UPDATE items SET stock = stock - $qty WHERE id = $id AND stock >= $qty и сортировка позиций заказа по item_id перед списанием. Здесь — почему это работает на уровне СУБД, и что ещё стоит знать про блокировки Postgres сверх этого конкретного случая.

Уровни изоляции: что каждый на самом деле предотвращает

Postgres поддерживает четыре уровня изоляции по стандарту SQL, но реализует только три различимых поведения (READ UNCOMMITTED в Postgres ведёт себя как READ COMMITTED — грязного чтения тут нет никогда, даже на самом слабом уровне):

  • READ COMMITTED (дефолт) — каждый оператор внутри транзакции видит снимок данных, зафиксированный на момент НАЧАЛА этого оператора. Предотвращает dirty read (чтение незакоммиченных чужих данных). НЕ предотвращает non-repeatable read (два SELECT одной и той же строки в одной транзакции могут вернуть разные значения, если между ними кто-то успел закоммитить) и phantom read (повторный SELECT по условию может найти новые строки).
  • REPEATABLE READ — снимок фиксируется на момент начала всей транзакции, а не каждого оператора. Дополнительно предотвращает non-repeatable read и phantom read. Не предотвращает write skew — ситуацию, где две транзакции читают пересекающиеся данные, каждая пишет в свою непересекающуюся часть, но вместе итоговое состояние нарушает инвариант, который был бы соблюдён при последовательном выполнении.
  • SERIALIZABLE — Postgres гарантирует результат, эквивалентный какому-то последовательному порядку выполнения транзакций, вплоть до отмены (serialization failure, код ошибки 40001) одной из конфликтующих транзакций, если гарантию иначе не удержать. Это единственный уровень, устраняющий write skew.

Главный факт не про список аномалий, а про конкретный кейс списания остатка: READ COMMITTED не спасает от read-then-write гонки, даже если в коде нет ни одной классической аномалии чтения. Проблема не в том, что транзакция видит "неправильные" данные — проблема в том, что между SELECT stock и последующим UPDATE stock = <вычисленное значение> эти два действия физически не атомарны, и вторая параллельная транзакция может успеть сделать то же самое между ними.

READ COMMITTED честно показывает каждой транзакции актуальный на момент SELECT остаток — просто ничего не мешает второй транзакции прочитать тот же остаток чуть позже, но всё ещё до того, как первая закоммитит своё изменение.

Проверено вживую (одноразовый контейнер postgres:16, таблица items(id, stock), stock=10, две параллельные транзакции, каждая читает остаток, "думает" 2 секунды (pg_sleep, имитация работы приложения между чтением и записью), затем пишет прочитанное_значение - 5):

BEGIN; SELECT stock ...; pg_sleep(2); UPDATE items SET stock = :прочитанное - 5; COMMIT;

Обе транзакции читают stock=10 до того, как любая из них закоммитила — обе вычисляют 10-5=5, обе успешно обновляют строку (UPDATE 1 у каждой, без единой ошибки или конфликта). Итоговый остаток: 5, хотя корректный результат при двух списаниях по 5 из 10 — 0. Один запрос молча "потерялся" — классический lost update, и READ COMMITTED тут ничего не сигнализирует, обе транзакции коммитятся без единой ошибки.

Read-then-write (гонка)
BEGIN;
SELECT stock FROM items WHERE id = $1;
-- в Go: newStock := stock - qty
UPDATE items SET stock = $2 WHERE id = $1;
COMMIT;
Атомарный UPDATE
BEGIN;
UPDATE items
SET stock = stock - $2
WHERE id = $1 AND stock >= $2;
COMMIT;

Тот же эксперимент с атомарным UPDATE items SET stock = stock - 5 WHERE id=1 AND stock >= 5 вместо двухшагового чтения-записи: обе параллельные транзакции коммитятся, итоговый остаток — 0, ровно как ожидается. Разница не в уровне изоляции (обе версии выполнялись на дефолтном READ COMMITTED) — разница в том, что вычисление нового значения происходит внутри одного SQL-оператора, на актуальных данных строки в момент её физической блокировки движком, а не на значении, вычисленном заранее в приложении.

Проверено дополнительно: SELECT ... FOR UPDATE перед чтением тоже чинит гонку (вторая транзакция блокируется на SELECT ... FOR UPDATE, пока первая не закоммитит, и видит уже обновлённое значение) — тот же эксперимент с FOR UPDATE вместо простого SELECT тоже даёт корректный итог 0. Разница с атомарным UPDATE ... WHERE: FOR UPDATE держит блокировку строки на всё время транзакции (включая pg_sleep/бизнес-логику между чтением и записью), атомарный UPDATE — только на время самого оператора, что и обсуждается в статье про Avito.Кухня как причина выбрать именно его для высокого RPS.

FOR UPDATE SKIP LOCKED — очередь задач без конкуренции воркеров

SELECT ... FOR UPDATE SKIP LOCKED — блокирует найденные строки как обычный FOR UPDATE, но пропускает строки, уже заблокированные другой транзакцией, вместо того чтобы ждать их освобождения:

SELECT id, payload FROM outbox_events
WHERE published_at IS NULL
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Это стандартный паттерн "таблица Postgres как очередь задач": несколько воркеров одновременно выполняют такой запрос, и каждый гарантированно получает свой набор строк — ни один не ждёт, ни один не заберёт то, что уже в обработке у другого. Без SKIP LOCKED (или тем более без FOR UPDATE вообще) несколько воркеров либо сериализовались бы в очередь ожидания блокировки, либо — что хуже — прочитали и попытались опубликовать одни и те же неопубликованные строки одновременно.

Прямое следствие для проекта Avito.Кухня из статьи выше: relay-воркер outbox читает неопубликованные строки обычным SELECT без FOR UPDATE SKIP LOCKED — при одном инстансе relay это не проблема, но при горизонтальном масштабировании (два инстанса relay одновременно) оба возьмут одни и те же строки и попытаются опубликовать их дважды.

Для system-consumer'а это не катастрофа (Kafka-consumer идемпотентен по построению, дубль сообщения — ожидаемый и безопасный случай at-least-once доставки), но конкретно это — известный, осознанно называемый на интервью пробел, а не скрытый баг: "single-instance relay безопасен как есть; для горизонтального масштабирования нужен FOR UPDATE SKIP LOCKED в запросе, который читает неопубликованные строки".

Deadlock: обнаружение и единый порядок блокировок

Postgres не предотвращает deadlock заранее — он его обнаруживает после факта: если транзакция ждёт блокировку дольше deadlock_timeout (по умолчанию 1 секунда), Postgres строит граф ожидания блокировок и проверяет его на цикл. Если цикл найден — одна из транзакций (обычно та, что определяется дешевле откатить) получает ошибку deadlock detected и откатывается, вторая продолжает как ни в чём не бывало.

Классический сценарий: транзакция A блокирует строку товара X, затем пытается заблокировать Y; транзакция B в это же время уже заблокировала Y и пытается заблокировать X — обе ждут друг друга бесконечно, пока не сработает обнаружение deadlock.

Единый порядок захвата блокировок — общий паттерн против этого класса проблем, а не специфика одного проекта. Если все транзакции, которым может понадобиться заблокировать несколько строк, всегда берут их в одном и том же порядке (например, по возрастанию id), цикл ожидания в принципе невозможен построить — та транзакция, что должна ждать вторую строку, физически не может оказаться в ситуации, где кто-то ждёт именно её первую строку.

Сортировка позиций заказа по item_id перед последовательным списанием (уже упомянутая в статье про Avito.Кухня) — конкретное применение этого общего правила, не изобретение под конкретный проект; тот же приём применим к любой системе, где одна логическая операция берёт блокировки на несколько строк.

Advisory locks — блокировка без строки-носителя

pg_advisory_lock(key) / pg_advisory_lock(key1, key2) — блокировка на произвольное число (или пару чисел), не привязанная ни к какой строке таблицы. Полезна там, где нет естественной строки, которую можно заблокировать через FOR UPDATE, но нужна гарантия "не больше одного одновременного выполнения" — например, пересчёт агрегированного отчёта за период, где блокировать пришлось бы сразу произвольный диапазон строк, или разовая миграция/джоба, которая не должна запуститься в двух инстансах приложения одновременно.

_, err := db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", lockKey)
// ... критическая секция ...
_, err = db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockKey)

Транзакционная версия pg_advisory_xact_lock освобождается автоматически в конце транзакции (COMMIT/ROLLBACK), без риска забыть явный unlock при панике или раннем return — обычно предпочтительнее сессионной версии именно поэтому.

Проверено вживую

Все три сценария в разделе про изоляцию выше — не теоретические утверждения, а результат реального эксперимента на одноразовом контейнере postgres:16 (поднят и снесён в рамках подготовки этой страницы, не оставлен работать): таблица из одной строки stock=10, две параллельные транзакции по -5, три варианта конкурентного доступа.

Read-then-write без блокировки дал потерянное обновление (итог 5 вместо 0) — обе транзакции прошли без единой ошибки. Атомарный UPDATE ... WHERE и SELECT ... FOR UPDATE оба дали корректный итог 0 — разными механизмами (более короткая блокировка на один оператор против удержания блокировки строки на всю транзакцию).

Самопроверка 0 / 5
Могу объяснить, почему READ COMMITTED не защищает от read-then-write гонки, даже без единой аномалии чтения
Знаю, для чего нужен FOR UPDATE SKIP LOCKED и почему без него несколько воркеров конкурируют за одни и те же строки
Понимаю, почему единый порядок блокировки строк (сортировка по ID) — общий паттерн против deadlock, а не хак для одного проекта
Знаю, когда использовать pg_advisory_lock вместо блокировки конкретной строки
Могу назвать, какую аномалию (dirty/non-repeatable/phantom read, write skew) предотвращает каждый уровень изоляции
Как усвоено?